5.9 KB205 lines
Blame
1import type { Metadata } from "next";
2import Link from "next/link";
3import { createHighlighter, type Highlighter } from "shiki";
4import { formatSize, encodePath } from "@/lib/utils";
5import { getRepoBlob } from "@/lib/grove-api";
6
7let highlighter: Highlighter | null = null;
8
9async function getHighlighter() {
10 if (!highlighter) {
11 highlighter = await createHighlighter({
12 themes: ["vitesse-light", "vitesse-dark"],
13 langs: [
14 "typescript", "tsx", "javascript", "jsx", "json", "markdown",
15 "css", "html", "python", "rust", "go", "ruby", "yaml", "toml",
16 "bash", "sql", "graphql", "xml", "c", "cpp", "java", "kotlin",
17 "swift", "lua", "diff", "dockerfile", "makefile", "ini",
18 ],
19 });
20 }
21 return highlighter;
22}
23
24interface Props {
25 params: Promise<{ owner: string; repo: string; path: string[] }>;
26}
27
28export async function generateMetadata({ params }: Props): Promise<Metadata> {
29 const { repo, path: pathParts } = await params;
30 const filePath = pathParts.slice(1).join("/");
31 const fileName = filePath.split("/").pop() ?? filePath;
32 return { title: `${fileName} · ${repo}` };
33}
34
35
36function getLang(filename: string): string {
37 const ext = filename.split(".").pop()?.toLowerCase() ?? "";
38 const map: Record<string, string> = {
39 ts: "typescript",
40 tsx: "tsx",
41 js: "javascript",
42 jsx: "jsx",
43 json: "json",
44 md: "markdown",
45 css: "css",
46 html: "html",
47 py: "python",
48 rs: "rust",
49 go: "go",
50 rb: "ruby",
51 yml: "yaml",
52 yaml: "yaml",
53 toml: "toml",
54 sh: "bash",
55 bash: "bash",
56 zsh: "bash",
57 sql: "sql",
58 graphql: "graphql",
59 svg: "xml",
60 xml: "xml",
61 c: "c",
62 cpp: "cpp",
63 h: "c",
64 hpp: "cpp",
65 java: "java",
66 kt: "kotlin",
67 swift: "swift",
68 lua: "lua",
69 diff: "diff",
70 };
71 const nameMap: Record<string, string> = {
72 Dockerfile: "dockerfile",
73 Makefile: "makefile",
74 ".gitignore": "gitignore",
75 ".gitmodules": "ini",
76 };
77 if (filename.startsWith("Dockerfile")) return "dockerfile";
78 if (filename.startsWith("Makefile")) return "makefile";
79 return nameMap[filename] ?? map[ext] ?? "text";
80}
81
82export default async function BlobPage({ params }: Props) {
83 const { owner, repo, path: pathParts } = await params;
84 const ref = pathParts[0] ?? "main";
85 const path = pathParts.slice(1).join("/");
86 const filename = pathParts[pathParts.length - 1];
87
88 const blob = await getRepoBlob(owner, repo, ref, path);
89
90 if (!blob) {
91 return (
92 <div className="max-w-3xl mx-auto px-4 py-16">
93 <h1 className="text-lg" style={{ color: "var(--text-secondary)" }}>
94 File not found
95 </h1>
96 </div>
97 );
98 }
99
100 const lines = blob.content.split("\n");
101 const lang = getLang(filename);
102
103 let highlighted: string | null = null;
104 try {
105 const hl = await getHighlighter();
106 const loadedLangs = hl.getLoadedLanguages();
107 const effectiveLang = loadedLangs.includes(lang) ? lang : "text";
108 highlighted = hl.codeToHtml(blob.content, {
109 lang: effectiveLang,
110 themes: {
111 light: "vitesse-light",
112 dark: "vitesse-dark",
113 },
114 defaultColor: false,
115 });
116 } catch (e) {
117 console.error(`[shiki] Failed to highlight ${filename} as ${lang}:`, e);
118 }
119
120 let htmlLines: string[] | null = null;
121 if (highlighted) {
122 const codeMatch = highlighted.match(/<code[^>]*>([\s\S]*)<\/code>/);
123 if (codeMatch) {
124 htmlLines = codeMatch[1].split("\n");
125 }
126 }
127
128 return (
129 <div className="px-4 py-6">
130 <div
131 style={{
132 border: "1px solid var(--border-subtle)",
133 }}
134 >
135 <div
136 className="flex items-center justify-between px-3 py-2 text-xs"
137 style={{
138 color: "var(--text-muted)",
139 backgroundColor: "var(--bg-inset)",
140 borderBottom: "1px solid var(--border-subtle)",
141 }}
142 >
143 <div className="flex items-center gap-4">
144 <span>{formatSize(blob.size)}</span>
145 <span>{lines.length} lines</span>
146 </div>
147 <div className="flex items-center gap-3">
148 <Link
149 href={`/${owner}/${repo}/blame/${ref}/${encodePath(path)}`}
150 style={{ color: "var(--accent)" }}
151 className="hover:underline"
152 >
153 Blame
154 </Link>
155 </div>
156 </div>
157
158 <div className="overflow-x-auto shiki">
159 <table className="w-full text-sm font-mono">
160 <tbody>
161 {htmlLines
162 ? htmlLines.map((html, i) => (
163 <tr key={i}>
164 <td
165 className="text-right select-none pr-4 pl-3 py-0 w-10"
166 style={{
167 color: "var(--text-faint)",
168 borderRight: "1px solid var(--divide)",
169 }}
170 >
171 {i + 1}
172 </td>
173 <td
174 className="pl-4 py-0 whitespace-pre"
175 dangerouslySetInnerHTML={{ __html: html || "&nbsp;" }}
176 />
177 </tr>
178 ))
179 : lines.map((line: string, i: number) => (
180 <tr key={i}>
181 <td
182 className="text-right select-none pr-4 pl-3 py-0 w-10"
183 style={{
184 color: "var(--text-faint)",
185 borderRight: "1px solid var(--divide)",
186 }}
187 >
188 {i + 1}
189 </td>
190 <td
191 className="pl-4 py-0 whitespace-pre"
192 style={{ color: "var(--text-secondary)" }}
193 >
194 {line}
195 </td>
196 </tr>
197 ))}
198 </tbody>
199 </table>
200 </div>
201 </div>
202 </div>
203 );
204}
205